# −*− coding: UTF−8 −*−
"""
Naïve queue service designed to be run off cron or similar.

TODO: create management commands to actually allow us to do that.
"""
from django.db import models
from django.db.models.query import QuerySet
from django.core.urlresolvers import get_callable
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes import generic
from ticket.utils import qualified_name
from ticket.exceptions import TransientFailure, PermanentFailure

STATUS_CHOICES=(
  ('NEW', 'NEW'),
  #('COMPLETED', 'COMPLETED'), #this should never be stored in the DB
  ('PROCESSING', 'PROCESSING'),
  ('TRANSIENT_FAILURE', 'TRANSIENT FAILURE'),
  ('PERMANENT_FAILURE', 'PERMANENT FAILURE'),
  ('UNCAUGHT_EXCEPTION', 'UNCAUGHT_EXCEPTION'),
)
VALID_STATUSES = set([key for key, val in STATUS_CHOICES])

class QueueQuerySet(QuerySet):
    def process_next(self, order_by="?", candidates=None):
        """
        Pops a given number off the tabled stack and handles them.
        assume failures are handled gracefully by the function
        returns the handled ticket, for ease of debugging.
        
        TODO: this is not transaction safe or concurrency smart
        in any meaningful sense. We assume that the consequences
        of simultaneous update are not catastrophic or deadlocking.
        
        once Django's ORM supports SELECT FOR UPDATE this could
        be improved somewhat. But if you are doing something massively
        concurrent, why aren't you using a specialist messaging server?
        
        pass in a query set to pull tickets from; it defaults to all active
        
        the processed ticket is returned if there are any
        """
        next=self.next(order_by=order_by, candidates=candidates)
        if next:
            ticket=self.process(next)
            return ticket

    def process(self, ticket):
        #this doesn't do safe locking but it does let us know which tickets are hung.
        ticket.status='PROCESSING'
        ticket.save()
        try:
            # import and run the queued function on the instance.
            function = get_callable(ticket.function)
            if ticket.inst: #silently fail if record is gone.
                function(ticket.inst)
        except TransientFailure, exc:
            #requeue
            ticket.status='TRANSIENT_FAILURE'
            ticket.error_msg=unicode(exc)
            ticket.save()
            return ticket
        except PermanentFailure, exc:
            #dequeue but leave in the database
            #TODO: notify admins
            ticket.status='PERMANENT_FAILURE'
            ticket.error_msg=unicode(exc)
            ticket.save()
            return ticket
        except Exception, exc:
            exc._ticket=ticket
            try:
                #uncaught exceptions could even be databasey errors
                ticket.status='UNCAUGHT_EXCEPTION'
                ticket.error_msg=unicode(exc)
                ticket.save()
            except:
                pass
            from django.conf import settings
            if getattr(settings, 'TICKETS_RAISE_UNCAUGHT_EXCEPTIONS', False):
                raise #exception inst now has failed ticket attached.
            else:
                return ticket
        else:
            #success! get it out of the DB.
            ticket.error_msg=''
            ticket.status='COMPLETED'
            ticket.delete()
            return ticket
                    
    def next(self, order_by='?', candidates=None):
        if candidates is None:
            candidates = self.active()
        candidates = candidates.order_by(order_by)
        if candidates.count():
            return candidates[0]

    def active(self):
        """
        return a queryset of thing not marked as intractable failures
        """
        return self.exclude(status__in=("PERMANENT_FAILURE", 'PROCESSING')) 
    def filter_by_func(self, func):
        return self.filter(function=qualified_name(func))
    def filter_by_inst(self, inst):
        ct, pk=coerce_inst_to_contenttype_tuple(inst)
        return self.filter(content_type=ct, object_id=pk)
    
class QueueManager(models.Manager):
    """
    naïve queue manager that handles a table of things to be done. 
    missing some important things - say, logging. and timeout management.
    """
    
    @staticmethod
    def _enqueue_keys(inst, func):
        func=coerce_fn_to_string(func)
        ct, pk=coerce_inst_to_contenttype_tuple(inst)
        return ct, pk, func
        
    def enqueue_idempotent(self, inst, func):
        """
        add something to the queue if there is nothing else there like it already.
        can't use get_or_create, because there might be two of them.
        """
        ct, pk, func = self._enqueue_keys(inst, func)
        candidates = self.filter(content_type=ct, object_id=pk, function=func)
        if candidates.count():
            return candidates[0]
        ticket = self.create(content_type=ct, object_id=pk, function=func)
        return ticket
        
    def enqueue(self, inst, func):
        ct, pk, func=self._enqueue_keys(inst, func)
        ticket = self.create(content_type=ct, object_id=pk, function=func)
        return ticket
        
    def get_query_set(self):
        return QueueQuerySet(self.model)
    def active(self):
        return self.all().active()
    def process_next(self):
        return self.active().process_next()
    def filter_by_func(self, func):
        return self.active().filter_by_func(func)
    def filter_by_inst(self, inst):
        return self.active().filter_by_inst(inst)

class Ticket(models.Model):
    """
    minimal queue model. records a function to be called on an instance, and a failure state
    """
    modified         = models.DateTimeField(auto_now_add=1, editable=False)
    created          = models.DateTimeField(auto_now=1, editable=False)
    function         = models.CharField(max_length=200)
    status           = models.CharField(db_index=True, max_length=20, choices=STATUS_CHOICES, default=STATUS_CHOICES[0][0])
    error_msg        = models.TextField(blank=True)
    content_type     = models.ForeignKey(ContentType)
    object_id        = models.PositiveIntegerField()
    inst             = generic.GenericForeignKey('content_type', 'object_id')
    
    objects=QueueManager()
    
    def save(self, *args, **kwargs):
        """
        hand-rolled validation because django's choices option is pissweak
        """
        if self.status and not self.status in VALID_STATUSES:
            from django.db import IntegrityError
            raise IntegrityError("invalid ticket status '%s'" % (self.status,))
        super(Ticket, self).save(*args, **kwargs)
        
    def __unicode__(self):
        return "%s queued for %s instance with pk %s" % (self.function, self.inst.__class__.__name__, str(self.object_id))

def coerce_fn_to_string(func):
    if not isinstance(func, basestring):
        func=qualified_name(func)
    return func

def coerce_inst_to_contenttype_tuple(inst):
    ct=ContentType.objects.get_for_model(inst)
    pk=inst.pk
    return ct, pk
